Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\xtask\src\readme.rs
Line
Count
Source
1
//! README help-section verification and update logic.
2
//!
3
//! The README embeds the `--help` output between two HTML comment delimiters:
4
//!
5
//! ````text
6
//! <!-- HELP_OUTPUT_START -->
7
//! ```cmd
8
//! cssh-rs.exe --help
9
//! <help content>
10
//! ```
11
//! <!-- HELP_OUTPUT_END -->
12
//! ````
13
//!
14
//! [`check_readme_help`] fails when the embedded text differs from the live
15
//! output. [`update_readme_help`] rewrites the README when they differ and
16
//! signals the change to the caller so a pre-commit hook can abort.
17
18
use std::sync::LazyLock;
19
20
use anyhow::{bail, Context, Result};
21
use cssh_rs_meta::PACKAGE_NAME;
22
23
const START_MARKER: &str = "<!-- HELP_OUTPUT_START -->";
24
const END_MARKER: &str = "<!-- HELP_OUTPUT_END -->";
25
static PREAMBLE: LazyLock<String> =
26
1
    LazyLock::new(|| format!("\r\n```cmd\r\n{PACKAGE_NAME}.exe --help\r\n"));
27
const POSTAMBLE: &str = "\r\n```\r\n";
28
29
/// All side-effecting operations required by this module.
30
///
31
/// Implement with mocks in tests to achieve zero filesystem and process
32
/// side-effects.
33
pub trait ReadmeSystem {
34
    /// Run `cargo run --package cssh-rs -- --help` and return the captured output.
35
    ///
36
    /// # Errors
37
    ///
38
    /// Returns an error if the process cannot be started or exits non-zero.
39
    fn get_help_output(&self) -> Result<String>;
40
41
    /// Read the full contents of `README.md`.
42
    ///
43
    /// # Errors
44
    ///
45
    /// Returns an error if the file cannot be read.
46
    fn read_readme(&self) -> Result<String>;
47
48
    /// Write `content` to `README.md`.
49
    ///
50
    /// # Errors
51
    ///
52
    /// Returns an error if the write fails.
53
    fn write_readme(&self, content: &str) -> Result<()>;
54
}
55
56
/// Production implementation of [`ReadmeSystem`].
57
pub struct RealSystem;
58
59
#[cfg_attr(coverage_nightly, coverage(off))]
60
impl ReadmeSystem for RealSystem {
61
    fn get_help_output(&self) -> Result<String> {
62
        let output = std::process::Command::new("cargo")
63
            .args(["run", "--package", PACKAGE_NAME, "--", "--help"])
64
            .output()
65
            .with_context(|| {
66
                format!("failed to run `cargo run --package {PACKAGE_NAME} -- --help`")
67
            })?;
68
        let raw = String::from_utf8_lossy(&output.stdout).into_owned();
69
        Ok(raw)
70
    }
71
72
    fn read_readme(&self) -> Result<String> {
73
        std::fs::read_to_string("README.md").context("failed to read README.md")
74
    }
75
76
    fn write_readme(&self, content: &str) -> Result<()> {
77
        std::fs::write("README.md", content).context("failed to write README.md")
78
    }
79
}
80
81
/// Normalize raw `--help` output for comparison with the README section.
82
///
83
/// Replaces lines that contain only whitespace with empty lines, normalizes
84
/// all line endings to `\r\n`, and trims leading and trailing whitespace.
85
///
86
/// # Arguments
87
///
88
/// * `raw` - Raw output from `--help`, possibly with mixed line endings.
89
///
90
/// # Returns
91
///
92
/// Normalized string ready for comparison with the README section.
93
///
94
6
pub fn normalize_help_output(raw: &str) -> String {
95
6
    let normalized: Vec<&str> = raw
96
6
        .lines()
97
9
        .
map6
(|line| if line.trim().is_empty() {
""1
} else {
line8
})
98
6
        .collect();
99
6
    let joined = normalized.join("\r\n");
100
6
    joined.trim().to_owned()
101
6
}
102
103
/// Extract the help text embedded in the README between the delimiters.
104
///
105
/// # Arguments
106
///
107
/// * `readme` - Full README contents.
108
///
109
/// # Returns
110
///
111
/// The help content string (trimmed), or an error if either delimiter is missing.
112
///
113
/// # Errors
114
///
115
/// Returns an error if `<!-- HELP_OUTPUT_START -->` or `<!-- HELP_OUTPUT_END -->`
116
/// is absent, or if the expected preamble/postamble structure is not found.
117
7
pub fn extract_readme_help_section(readme: &str) -> Result<&str> {
118
7
    let 
start_marker_pos6
= readme
119
7
        .find(START_MARKER)
120
7
        .context("could not find <!-- HELP_OUTPUT_START --> in README.md")
?1
;
121
6
    let 
end_marker_pos5
= readme
122
6
        .find(END_MARKER)
123
6
        .context("could not find <!-- HELP_OUTPUT_END --> in README.md")
?1
;
124
125
5
    let content_start = start_marker_pos + START_MARKER.len() + PREAMBLE.len();
126
5
    let content_end = end_marker_pos - POSTAMBLE.len();
127
128
5
    if content_start > content_end {
129
0
        bail!("README help section delimiters are malformed or out of order");
130
5
    }
131
132
5
    Ok(readme[content_start..content_end].trim())
133
7
}
134
135
/// Rebuild the README with the help section replaced by `new_help`.
136
///
137
/// All content outside the delimiters and the fixed preamble/postamble is
138
/// preserved byte-for-byte.
139
///
140
/// # Arguments
141
///
142
/// * `readme` - Full README contents.
143
/// * `new_help` - Normalized help text to embed.
144
///
145
/// # Returns
146
///
147
/// New full README string.
148
///
149
/// # Errors
150
///
151
/// Returns an error if the delimiters are not found.
152
2
pub fn replace_readme_help_section(readme: &str, new_help: &str) -> Result<String> {
153
2
    let start_marker_pos = readme
154
2
        .find(START_MARKER)
155
2
        .context("could not find <!-- HELP_OUTPUT_START --> in README.md")
?0
;
156
2
    let end_marker_pos = readme
157
2
        .find(END_MARKER)
158
2
        .context("could not find <!-- HELP_OUTPUT_END --> in README.md")
?0
;
159
160
2
    let content_start = start_marker_pos + START_MARKER.len() + PREAMBLE.len();
161
2
    let content_end = end_marker_pos - POSTAMBLE.len();
162
163
2
    let before = &readme[..content_start];
164
2
    let after = &readme[content_end..];
165
166
2
    Ok(format!("{before}{new_help}{after}"))
167
2
}
168
169
/// Compare the live `--help` output against the README's embedded help section.
170
///
171
/// Prints a colored diff to stdout when they differ.
172
///
173
/// # Arguments
174
///
175
/// * `system` - Injected I/O provider.
176
///
177
/// # Returns
178
///
179
/// `Ok(())` if they match; an error describing the mismatch otherwise.
180
///
181
/// # Errors
182
///
183
/// Returns an error when the sections differ or when any I/O operation fails.
184
2
pub fn check_readme_help<S: ReadmeSystem>(system: &S) -> Result<()> {
185
2
    let raw_help = system.get_help_output()
?0
;
186
2
    let actual_help = normalize_help_output(&raw_help);
187
188
2
    let readme = system.read_readme()
?0
;
189
2
    let readme_help = extract_readme_help_section(&readme)
?0
;
190
191
2
    if actual_help == readme_help {
192
1
        log::info!("README.md help output is up to date.");
193
1
        return Ok(());
194
1
    }
195
196
1
    log::error!(
197
        "README.md help output is outdated!\n\
198
         \n\
199
         Differences found:\n\
200
         ==================\n\
201
         README has:\n\
202
         {readme_help}\n\
203
         \n\
204
         Current --help output:\n\
205
         {actual_help}\n\
206
         \n\
207
         ==> Run `cargo xtask update-readme-help` to fix this."
208
    );
209
210
1
    bail!("README.md help output is outdated")
211
2
}
212
213
/// Ensure the README's embedded help section matches the live `--help` output,
214
/// writing an updated README when they differ.
215
///
216
/// # Arguments
217
///
218
/// * `system` - Injected I/O provider.
219
///
220
/// # Returns
221
///
222
/// `Ok(true)` when the README was modified (the caller should exit with code 1
223
/// to abort a pre-commit hook); `Ok(false)` when already up to date.
224
///
225
/// # Errors
226
///
227
/// Returns an error when any I/O operation fails.
228
2
pub fn update_readme_help<S: ReadmeSystem>(system: &S) -> Result<bool> {
229
2
    let raw_help = system.get_help_output()
?0
;
230
2
    let actual_help = normalize_help_output(&raw_help);
231
232
2
    let readme = system.read_readme()
?0
;
233
2
    let readme_help = extract_readme_help_section(&readme)
?0
;
234
235
2
    if actual_help == readme_help {
236
1
        log::info!("README.md help section is up to date, nothing to be done.");
237
1
        return Ok(false);
238
1
    }
239
240
1
    log::warn!("README.md help section is outdated - fixing it.");
241
1
    let new_readme = replace_readme_help_section(&readme, &actual_help)
?0
;
242
1
    system.write_readme(&new_readme)
?0
;
243
1
    log::info!("README.md help section has been updated with current --help output.");
244
245
1
    Ok(true)
246
2
}
247
248
#[cfg(test)]
249
#[path = "tests/test_readme.rs"]
250
mod tests;